react-linear-feedback 0.1.0 β†’ 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,31 +1,40 @@
1
1
  # react-linear-feedback
2
2
 
3
- A drop-in feedback widget for any React app. A trusted user opens it with `?feedback`, **drags a box** over the page, picks **Bug / Improvement**, writes a note β€” and it captures an **annotated screenshot** and opens a **Linear issue**.
3
+ [![npm version](https://img.shields.io/npm/v/react-linear-feedback.svg)](https://www.npmjs.com/package/react-linear-feedback) [![license](https://img.shields.io/npm/l/react-linear-feedback.svg)](./LICENSE)
4
4
 
5
- - πŸͺΆ **Framework-agnostic** β€” Next.js, Vite, Remix, CRA… (no `next` dependency)
6
- - 🎨 **Self-contained styles** β€” injected at runtime, themeable with one prop; **no CSS import, no Tailwind, no design system** required
5
+ **A drop-in React feedback widget that turns a drawn box + note into a Linear issue with an annotated screenshot.**
6
+
7
+ A trusted user opens it with `?feedback`, **drags a box** over the page, picks a type (**Bug / Improvement**), writes a note β€” and it captures a screenshot and files a Linear issue. Framework-agnostic, self-contained styling, no design system required.
8
+
9
+ <!-- Demo: record a short GIF and drop it here β†’ ![demo](docs/demo.gif) -->
10
+ > _Draw a box anywhere β†’ type a note β†’ it lands in Linear with the annotated screenshot and page context._
11
+
12
+ - πŸͺΆ **Themeable in one prop** β€” no CSS import, no Tailwind, no design system; styles are injected at runtime
13
+ - 🌍 **Works in any React app** β€” Next.js, Vite, Remix, CRA… (no `next` dependency; `"use client"` is built in)
7
14
  - πŸ–ΌοΈ **Annotated screenshots** via [`modern-screenshot`](https://github.com/qq15725/modern-screenshot) (handles Tailwind v4 / `oklch()`)
8
- - 🏷️ **Labels resolved by name** at request time (recoloring/recreating a label in Linear won't break it), applied best-effort
9
- - πŸ”Œ Tiny server core + **Next.js & Node/Express adapters**
15
+ - 🏷️ **Labels by name, self-healing** β€” resolved at request time, so recoloring/recreating a label in Linear won't break it; applied best-effort
16
+ - πŸ”Œ **Tiny server core** + Next.js, Node/Express, and Vite-dev adapters
17
+
18
+ ## Contents
19
+
20
+ - [Install](#install) Β· [Quick start (Next.js)](#quick-start-nextjs) Β· [Vite / any backend](#use-with-vite--any-react-app) Β· [Linear setup](#linear-setup) Β· [Configuration](#configuration) Β· [Theming](#theming) Β· [Custom types](#custom-types) Β· [Security](#security) Β· [Troubleshooting](#troubleshooting) Β· [License](#license)
10
21
 
11
22
  ## Install
12
23
 
13
24
  ```bash
14
25
  npm i react-linear-feedback
15
- # the server entry needs the Linear SDK:
16
- npm i @linear/sdk
17
26
  ```
18
27
 
19
- `react` / `react-dom` are peer dependencies. `@linear/sdk` is an optional peer β€” only needed where you run the server handler.
28
+ `react` / `react-dom` are peer dependencies. `@linear/sdk` ships as a dependency β€” it's imported only by the server entry, so it's tree-shaken out of client bundles. Do [Linear setup](#linear-setup) first to get your API key, team, and labels.
20
29
 
21
- ## Quick start β€” Next.js (App Router)
30
+ ## Quick start (Next.js, App Router)
22
31
 
23
32
  **1. Server route** β€” `app/api/feedback/route.ts`:
24
33
 
25
34
  ```ts
26
35
  import { createNextRoute, cookieGate } from "react-linear-feedback/server";
27
36
 
28
- export const runtime = "nodejs"; // needs Buffer + the Linear SDK
37
+ export const runtime = "nodejs"; // REQUIRED β€” uses Buffer to process the screenshot; Edge is not supported
29
38
 
30
39
  export const POST = createNextRoute({
31
40
  apiKey: process.env.LINEAR_API_KEY!,
@@ -34,7 +43,7 @@ export const POST = createNextRoute({
34
43
  });
35
44
  ```
36
45
 
37
- **2. Mount the widget** once (e.g. in `app/layout.tsx`):
46
+ **2. Mount the widget once** (e.g. in `app/layout.tsx`):
38
47
 
39
48
  ```tsx
40
49
  import { FeedbackGate } from "react-linear-feedback/react";
@@ -49,62 +58,138 @@ export default function RootLayout({ children }) {
49
58
  }
50
59
  ```
51
60
 
52
- Visit any page with `?feedback` to turn it on (a cookie remembers it). `?feedback=0` turns it off.
61
+ No `"use client"` needed β€” the package ships it, so you can mount `<FeedbackGate>` straight from a Server Component layout.
62
+
63
+ **3. Turn it on.** The widget is **hidden by default**. Visit any page with **`?feedback`** (or `?feedback=1`) to enable it; a cookie remembers the choice for 90 days. `?feedback=0` turns it off.
53
64
 
54
- ## Quick start β€” Vite / SPA (separate backend)
65
+ ## Use with Vite / any React app
55
66
 
56
- Mount the widget and point it at your backend:
67
+ Unlike Next.js, a Vite SPA has **no server of its own** β€” so the handler runs as a serverless function (e.g. on Vercel) in production, and as a dev-server plugin locally. Same widget either way.
68
+
69
+ **1. Mount the widget** once, in your root component:
57
70
 
58
71
  ```tsx
59
72
  import { FeedbackGate } from "react-linear-feedback/react";
60
73
 
61
- <FeedbackGate endpoint="https://api.example.com/feedback" brandColor="#7f56d9" />
74
+ <FeedbackGate brandColor="#7f56d9" />; // endpoint defaults to /api/feedback (same origin)
75
+ ```
76
+
77
+ **2. Production β€” a Vercel serverless function** at `api/feedback.ts`:
78
+
79
+ ```ts
80
+ import { createNodeHandler, cookieGate } from "react-linear-feedback/server";
81
+
82
+ // Node runtime (the default for /api functions) β€” Edge has no Buffer for the screenshot upload.
83
+ export default createNodeHandler({
84
+ apiKey: process.env.LINEAR_API_KEY!,
85
+ teamId: process.env.LINEAR_TEAM_ID!,
86
+ authorize: cookieGate("wh_feedback"),
87
+ });
88
+ ```
89
+
90
+ Set `LINEAR_API_KEY` / `LINEAR_TEAM_ID` in your Vercel project's env (server-side β€” **not** `VITE_`-prefixed, so they never reach the bundle). The function is same-origin as the SPA, so no CORS needed.
91
+
92
+ **3. Local dev β€” the Vite plugin**, so `vite dev` serves the same endpoint (without it, `POST /api/feedback` 404s locally):
93
+
94
+ ```ts
95
+ // vite.config.ts
96
+ import { defineConfig, loadEnv } from "vite";
97
+ import { linearFeedback } from "react-linear-feedback/vite";
98
+ import { cookieGate } from "react-linear-feedback/server";
99
+
100
+ export default defineConfig(({ mode }) => {
101
+ const env = loadEnv(mode, process.cwd(), ""); // reads .env (LINEAR_* are server-side, un-prefixed)
102
+ return {
103
+ plugins: [
104
+ linearFeedback({
105
+ apiKey: env.LINEAR_API_KEY,
106
+ teamId: env.LINEAR_TEAM_ID,
107
+ authorize: cookieGate("wh_feedback"),
108
+ }),
109
+ ],
110
+ };
111
+ });
62
112
  ```
63
113
 
64
- Run the handler on any Node server (Express shown):
114
+ The plugin is dev-only (`apply: "serve"`) β€” it has no effect on the production build.
115
+
116
+ ### Other Node servers (Express, Hono, …)
117
+
118
+ `createNodeHandler` is a plain `(req, res)` handler that reads the raw body itself (works with or without `express.json()`):
65
119
 
66
120
  ```ts
67
121
  import express from "express";
68
- import { createNodeHandler } from "react-linear-feedback/server";
122
+ import { createNodeHandler, cookieGate } from "react-linear-feedback/server";
69
123
 
70
124
  const app = express();
71
- app.post("/feedback", createNodeHandler({
125
+ app.post("/api/feedback", createNodeHandler({
72
126
  apiKey: process.env.LINEAR_API_KEY!,
73
127
  teamId: process.env.LINEAR_TEAM_ID!,
128
+ authorize: cookieGate("wh_feedback"),
74
129
  }));
75
130
  app.listen(8787);
76
131
  ```
77
132
 
78
- (Enable CORS for your site origin if the API is on a different host.)
133
+ If the SPA and API are on **different origins**, set `allowedOrigin: "https://your-site.com"` and enable CORS (`credentials: true` so the cookie is sent).
134
+
135
+ ## Linear setup
136
+
137
+ You need three things from Linear:
138
+
139
+ 1. **API key** β€” Linear β†’ **Settings β†’ Security & access β†’ Personal API keys** β†’ create one. Keep it server-side as `LINEAR_API_KEY`.
140
+ 2. **Team UUID** β€” the team issues land in. Find it via the Linear API (`viewer`/`teams` query) or your team settings; set it as `LINEAR_TEAM_ID`.
141
+ 3. **Labels** β€” each feedback type is matched to a Linear label **by name** (case-insensitive). With the defaults, make sure labels named **`bug`** and **`improvement`** exist in that team (or workspace). Missing labels don't fail the issue β€” it's just created without one. Remap names with the server [`labels`](#configuration) option.
142
+
143
+ ```bash
144
+ # .env.local
145
+ LINEAR_API_KEY=lin_api_...
146
+ LINEAR_TEAM_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
147
+ ```
79
148
 
80
149
  ## Configuration
81
150
 
82
- **`<FeedbackGate>` / `<FeedbackWidget>` props**
151
+ ### Widget props (`<FeedbackGate>` / `<FeedbackWidget>`)
152
+
153
+ Most apps only set `brandColor` and maybe `endpoint`.
83
154
 
84
155
  | Prop | Default | Description |
85
156
  | --- | --- | --- |
86
157
  | `endpoint` | `/api/feedback` | Where the widget POSTs |
87
- | `brandColor` | indigo | FAB / active / focus color (sets `--lfb-brand`) |
158
+ | `brandColor` | `#6366f1` | FAB / active / focus color (sets the `--lfb-brand` CSS var) |
88
159
  | `position` | `bottom-right` | `bottom-right` \| `bottom-left` \| `top-right` \| `top-left` |
89
160
  | `types` | Bug, Improvement | `{ id, label, color, icon? }[]` shown in the composer |
90
- | `nameRequired` | `true` | Ask for a reporter name before the first submission |
161
+ | `nameRequired` | `true` | Ask for a reporter name before the first submission (saved to localStorage, included in the issue) |
162
+ | `nameStorageKey` | `wh_feedback_name` | localStorage key for the remembered name |
91
163
  | `fabLabel` | `Give feedback` | Floating button text |
92
- | `urlParam` | `feedback` | Toggle param (gate only) |
93
- | `cookieName` | `wh_feedback` | Enabled-state cookie (gate only) |
164
+ | `urlParam` ΒΉ | `feedback` | Toggle param |
165
+ | `cookieName` ΒΉ | `wh_feedback` | Enabled-state cookie name |
166
+ | `cookieValue` ΒΉ | `1` | Cookie value when enabled |
167
+ | `cookieMaxAgeSeconds` ΒΉ | `7776000` (90d) | Cookie lifetime |
94
168
 
95
- **Server config** (`createNextRoute` / `createNodeHandler` / `createFeedbackIssue`)
169
+ ΒΉ `<FeedbackGate>` only.
170
+
171
+ ### Server config (`createNextRoute` / `createNodeHandler` / `createFeedbackIssue`)
96
172
 
97
173
  | Field | Description |
98
174
  | --- | --- |
99
175
  | `apiKey` | Linear personal API key (**server-side only**) |
100
176
  | `teamId` | Target team UUID |
101
- | `labels` | Optional `{ [typeId]: labelName }` map. Default: the type id is the label name (so `bug` β†’ label `bug`) |
102
- | `allowedOrigin` | Optional single-origin allowlist |
103
- | `authorize(req)` | Optional gate; return `false` to reject (`cookieGate` provided) |
177
+ | `labels` | Optional `{ [typeId]: labelName }` map. Default: the type id **is** the label name (so type `bug` β†’ label `bug`) |
178
+ | `allowedOrigin` | Optional single-origin allowlist (adapters) |
179
+ | `authorize(req)` | Optional gate; return `false` to reject. `cookieGate(name, value="1")` is provided |
180
+
181
+ Remap type ids to differently-named Linear labels:
182
+
183
+ ```ts
184
+ createNextRoute({
185
+ apiKey, teamId,
186
+ labels: { bug: "bug", idea: "feature-request" },
187
+ });
188
+ ```
104
189
 
105
190
  ## Theming
106
191
 
107
- Set `brandColor`, or override any CSS variable on `.lfb-doc-layer, .lfb-fixed-layer`:
192
+ Set `brandColor`, or override any CSS variable on `.lfb-root`:
108
193
  `--lfb-brand`, `--lfb-fg`, `--lfb-surface`, `--lfb-border`, `--lfb-radius`, `--lfb-rect`, `--lfb-z`, `--lfb-font`.
109
194
 
110
195
  ## Custom types
@@ -118,14 +203,32 @@ Set `brandColor`, or override any CSS variable on `.lfb-doc-layer, .lfb-fixed-la
118
203
  />
119
204
  ```
120
205
 
121
- Each `type.id` is matched to a Linear label of the same name (or remap via the server `labels` config).
206
+ Each `type.id` is matched to a Linear label of the same name. If your label is named differently, map it on the server via `labels` (above). Built-in `icon` values: `"bug"`, `"improvement"`, `"dot"`.
122
207
 
123
208
  ## Security
124
209
 
125
- The endpoint **creates Linear issues**, so it's effectively write access to your tracker. It's gated only by what you wire up β€” use `authorize` (e.g. `cookieGate`, a session check) and/or `allowedOrigin`, and add rate limiting if the page is public. Your `LINEAR_API_KEY` stays server-side; the package never ships it to the browser.
210
+ ⚠️ **The endpoint creates Linear issues**, so it's effectively write access to your tracker, and it's **open by default**. Before shipping on a public page:
211
+
212
+ - **Gate it** with `authorize` (e.g. `cookieGate`, or your own session check).
213
+ - **Restrict origin** with `allowedOrigin: "https://your-site.com"`.
214
+ - **Rate-limit** if the page is public (e.g. per-IP).
126
215
 
127
- > Linear asset URLs (the screenshots) are **private** β€” they render inside the issue but a fresh signed URL is needed to fetch them elsewhere.
216
+ Your `LINEAR_API_KEY` stays server-side; the package never ships it to the browser. Screenshots are uploaded to Linear's **private** asset storage β€” they render inside the issue, but the URL needs a fresh signed token to fetch elsewhere (it can't be hot-linked). If a screenshot upload fails, the issue is still created without it.
217
+
218
+ ## Troubleshooting
219
+
220
+ Submissions never throw in the UI β€” failures are logged to the browser console with a `[feedback]` prefix, and server errors return a JSON `message`. If issues aren't being created, check:
221
+
222
+ - `endpoint` points at your route, and `LINEAR_API_KEY` / `LINEAR_TEAM_ID` are set.
223
+ - **CORS** is configured when the app and API are on different origins.
224
+ - `runtime = "nodejs"` is set on the Next.js route (Edge has no `Buffer`).
225
+ - On a **Vite SPA**, `POST /api/feedback` 404s under `vite dev` unless you add the [`linearFeedback` Vite plugin](#use-with-vite--any-react-app) (or run `vercel dev`). In production it's served by your deployed function.
226
+ - The expected Linear **labels exist** (otherwise the issue is created without a label, with a warning).
128
227
 
129
228
  ## License
130
229
 
131
- MIT
230
+ MIT β€” see [LICENSE](./LICENSE).
231
+
232
+ ## Contributing
233
+
234
+ Issues and PRs welcome at [github.com/oliverodgaardwastehero/react-linear-feedback](https://github.com/oliverodgaardwastehero/react-linear-feedback).
@@ -169,7 +169,13 @@ var TYPE_ICONS = {
169
169
  // src/react/styles.ts
170
170
  var STYLE_ID = "lfb-styles";
171
171
  var CSS2 = `
172
- .lfb-doc-layer, .lfb-fixed-layer {
172
+ /*
173
+ * Defaults live on .lfb-root (the element that also receives the inline brandColor
174
+ * override) \u2014 NOT on the layers. The layers/FAB inherit from here. If the defaults
175
+ * sat on .lfb-doc-layer/.lfb-fixed-layer, a direct rule on those elements would beat
176
+ * the brandColor inherited from .lfb-root, so the \`brandColor\` prop would never apply.
177
+ */
178
+ .lfb-root {
173
179
  --lfb-brand: #6366f1;
174
180
  --lfb-fg: #181d27;
175
181
  --lfb-fg-secondary: #414651;
@@ -250,12 +256,24 @@ var CSS2 = `
250
256
  .lfb-stack--bottom-left { right: auto; left: 16px; align-items: flex-start; }
251
257
  .lfb-stack--top-right { bottom: auto; top: 16px; }
252
258
  .lfb-stack--top-left { bottom: auto; top: 16px; right: auto; left: 16px; align-items: flex-start; }
259
+ /* Edge tabs: anchored flush to a side, vertically centered. */
260
+ .lfb-stack--right { top: 50%; bottom: auto; right: 0; transform: translateY(-50%); align-items: flex-end; }
261
+ .lfb-stack--left { top: 50%; bottom: auto; left: 0; right: auto; transform: translateY(-50%); align-items: flex-start; }
262
+ /* Keep the transient name-prompt / sent-toast cards off the viewport edge (the tab stays flush). */
263
+ .lfb-stack--right > .lfb-card { margin-right: 12px; }
264
+ .lfb-stack--left > .lfb-card { margin-left: 12px; }
253
265
 
254
266
  .lfb-fab { display: inline-flex; align-items: center; gap: 8px; border: 0; border-radius: 9999px; padding: 12px 16px; font-size: 14px; font-weight: 600; font-family: var(--lfb-font); cursor: pointer; background: var(--lfb-brand); color: #fff; box-shadow: 0 10px 25px rgba(0,0,0,0.18); transition: transform 0.1s, background 0.1s; }
255
267
  .lfb-fab:hover { transform: scale(1.05); background: color-mix(in srgb, var(--lfb-brand) 88%, black); }
256
268
  .lfb-fab--active { background: var(--lfb-surface); color: var(--lfb-fg); border: 1px solid var(--lfb-border); }
257
269
  .lfb-fab--active:hover { background: var(--lfb-surface-hover); }
258
270
 
271
+ /* Edge-tab launcher: compact, icon-only, rounded on the inner side only, flush to the viewport edge. */
272
+ .lfb-fab--tab { gap: 0; padding: 14px 12px; border-radius: 12px 0 0 12px; box-shadow: -6px 0 20px rgba(0,0,0,0.18); }
273
+ .lfb-fab--tab:hover { transform: translateX(-2px); }
274
+ .lfb-stack--left .lfb-fab--tab { border-radius: 0 12px 12px 0; box-shadow: 6px 0 20px rgba(0,0,0,0.18); }
275
+ .lfb-stack--left .lfb-fab--tab:hover { transform: translateX(2px); }
276
+
259
277
  .lfb-toast { display: flex; align-items: flex-start; gap: 10px; width: 300px; max-width: calc(100vw - 32px); }
260
278
  .lfb-toast-icon { color: #17b26a; flex-shrink: 0; margin-top: 1px; }
261
279
  .lfb-toast-body { min-width: 0; flex: 1; }
@@ -287,6 +305,7 @@ function FeedbackWidget({
287
305
  nameStorageKey = "wh_feedback_name",
288
306
  fabLabel = "Give feedback"
289
307
  }) {
308
+ const isEdge = position === "right" || position === "left";
290
309
  const [mode, setMode] = react.useState({ kind: "idle" });
291
310
  const [name, setName] = react.useState("");
292
311
  const [nameDraft, setNameDraft] = react.useState("");
@@ -563,21 +582,18 @@ function FeedbackWidget({
563
582
  ] }),
564
583
  /* @__PURE__ */ jsxRuntime.jsx("button", { type: "button", className: "lfb-iconbtn", "aria-label": "Dismiss", onClick: () => setResult(null), children: /* @__PURE__ */ jsxRuntime.jsx(XIcon, {}) })
565
584
  ] }),
566
- /* @__PURE__ */ jsxRuntime.jsx(
585
+ /* @__PURE__ */ jsxRuntime.jsxs(
567
586
  "button",
568
587
  {
569
588
  type: "button",
570
- className: `lfb-fab${mode.kind === "idle" ? "" : " lfb-fab--active"}`,
589
+ className: `lfb-fab${isEdge ? " lfb-fab--tab" : ""}${mode.kind === "idle" ? "" : " lfb-fab--active"}`,
571
590
  "aria-label": mode.kind === "idle" ? fabLabel : "Cancel feedback",
591
+ title: isEdge ? mode.kind === "idle" ? fabLabel : "Cancel feedback" : void 0,
572
592
  onClick: startFlow,
573
- children: mode.kind === "idle" ? /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
574
- /* @__PURE__ */ jsxRuntime.jsx(MessageIcon, { size: 18 }),
575
- " ",
576
- fabLabel
577
- ] }) : /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
578
- /* @__PURE__ */ jsxRuntime.jsx(XIcon, { size: 18 }),
579
- " Cancel"
580
- ] })
593
+ children: [
594
+ mode.kind === "idle" ? /* @__PURE__ */ jsxRuntime.jsx(MessageIcon, { size: isEdge ? 22 : 18 }) : /* @__PURE__ */ jsxRuntime.jsx(XIcon, { size: isEdge ? 22 : 18 }),
595
+ !isEdge && /* @__PURE__ */ jsxRuntime.jsx("span", { children: mode.kind === "idle" ? fabLabel : "Cancel" })
596
+ ]
581
597
  }
582
598
  )
583
599
  ] })
@@ -66,7 +66,9 @@ type FeedbackResult = {
66
66
  };
67
67
 
68
68
  declare const DEFAULT_TYPES: FeedbackTypeOption[];
69
- type FeedbackPosition = "bottom-right" | "bottom-left" | "top-right" | "top-left";
69
+ type FeedbackPosition = "bottom-right" | "bottom-left" | "top-right" | "top-left"
70
+ /** Edge tabs: compact, icon-only launcher flush to the side, vertically centered. */
71
+ | "right" | "left";
70
72
  type FeedbackWidgetProps = {
71
73
  /** Endpoint that runs the server handler (default "/api/feedback"). */
72
74
  endpoint?: string;
@@ -66,7 +66,9 @@ type FeedbackResult = {
66
66
  };
67
67
 
68
68
  declare const DEFAULT_TYPES: FeedbackTypeOption[];
69
- type FeedbackPosition = "bottom-right" | "bottom-left" | "top-right" | "top-left";
69
+ type FeedbackPosition = "bottom-right" | "bottom-left" | "top-right" | "top-left"
70
+ /** Edge tabs: compact, icon-only launcher flush to the side, vertically centered. */
71
+ | "right" | "left";
70
72
  type FeedbackWidgetProps = {
71
73
  /** Endpoint that runs the server handler (default "/api/feedback"). */
72
74
  endpoint?: string;
@@ -167,7 +167,13 @@ var TYPE_ICONS = {
167
167
  // src/react/styles.ts
168
168
  var STYLE_ID = "lfb-styles";
169
169
  var CSS2 = `
170
- .lfb-doc-layer, .lfb-fixed-layer {
170
+ /*
171
+ * Defaults live on .lfb-root (the element that also receives the inline brandColor
172
+ * override) \u2014 NOT on the layers. The layers/FAB inherit from here. If the defaults
173
+ * sat on .lfb-doc-layer/.lfb-fixed-layer, a direct rule on those elements would beat
174
+ * the brandColor inherited from .lfb-root, so the \`brandColor\` prop would never apply.
175
+ */
176
+ .lfb-root {
171
177
  --lfb-brand: #6366f1;
172
178
  --lfb-fg: #181d27;
173
179
  --lfb-fg-secondary: #414651;
@@ -248,12 +254,24 @@ var CSS2 = `
248
254
  .lfb-stack--bottom-left { right: auto; left: 16px; align-items: flex-start; }
249
255
  .lfb-stack--top-right { bottom: auto; top: 16px; }
250
256
  .lfb-stack--top-left { bottom: auto; top: 16px; right: auto; left: 16px; align-items: flex-start; }
257
+ /* Edge tabs: anchored flush to a side, vertically centered. */
258
+ .lfb-stack--right { top: 50%; bottom: auto; right: 0; transform: translateY(-50%); align-items: flex-end; }
259
+ .lfb-stack--left { top: 50%; bottom: auto; left: 0; right: auto; transform: translateY(-50%); align-items: flex-start; }
260
+ /* Keep the transient name-prompt / sent-toast cards off the viewport edge (the tab stays flush). */
261
+ .lfb-stack--right > .lfb-card { margin-right: 12px; }
262
+ .lfb-stack--left > .lfb-card { margin-left: 12px; }
251
263
 
252
264
  .lfb-fab { display: inline-flex; align-items: center; gap: 8px; border: 0; border-radius: 9999px; padding: 12px 16px; font-size: 14px; font-weight: 600; font-family: var(--lfb-font); cursor: pointer; background: var(--lfb-brand); color: #fff; box-shadow: 0 10px 25px rgba(0,0,0,0.18); transition: transform 0.1s, background 0.1s; }
253
265
  .lfb-fab:hover { transform: scale(1.05); background: color-mix(in srgb, var(--lfb-brand) 88%, black); }
254
266
  .lfb-fab--active { background: var(--lfb-surface); color: var(--lfb-fg); border: 1px solid var(--lfb-border); }
255
267
  .lfb-fab--active:hover { background: var(--lfb-surface-hover); }
256
268
 
269
+ /* Edge-tab launcher: compact, icon-only, rounded on the inner side only, flush to the viewport edge. */
270
+ .lfb-fab--tab { gap: 0; padding: 14px 12px; border-radius: 12px 0 0 12px; box-shadow: -6px 0 20px rgba(0,0,0,0.18); }
271
+ .lfb-fab--tab:hover { transform: translateX(-2px); }
272
+ .lfb-stack--left .lfb-fab--tab { border-radius: 0 12px 12px 0; box-shadow: 6px 0 20px rgba(0,0,0,0.18); }
273
+ .lfb-stack--left .lfb-fab--tab:hover { transform: translateX(2px); }
274
+
257
275
  .lfb-toast { display: flex; align-items: flex-start; gap: 10px; width: 300px; max-width: calc(100vw - 32px); }
258
276
  .lfb-toast-icon { color: #17b26a; flex-shrink: 0; margin-top: 1px; }
259
277
  .lfb-toast-body { min-width: 0; flex: 1; }
@@ -285,6 +303,7 @@ function FeedbackWidget({
285
303
  nameStorageKey = "wh_feedback_name",
286
304
  fabLabel = "Give feedback"
287
305
  }) {
306
+ const isEdge = position === "right" || position === "left";
288
307
  const [mode, setMode] = useState({ kind: "idle" });
289
308
  const [name, setName] = useState("");
290
309
  const [nameDraft, setNameDraft] = useState("");
@@ -561,21 +580,18 @@ function FeedbackWidget({
561
580
  ] }),
562
581
  /* @__PURE__ */ jsx("button", { type: "button", className: "lfb-iconbtn", "aria-label": "Dismiss", onClick: () => setResult(null), children: /* @__PURE__ */ jsx(XIcon, {}) })
563
582
  ] }),
564
- /* @__PURE__ */ jsx(
583
+ /* @__PURE__ */ jsxs(
565
584
  "button",
566
585
  {
567
586
  type: "button",
568
- className: `lfb-fab${mode.kind === "idle" ? "" : " lfb-fab--active"}`,
587
+ className: `lfb-fab${isEdge ? " lfb-fab--tab" : ""}${mode.kind === "idle" ? "" : " lfb-fab--active"}`,
569
588
  "aria-label": mode.kind === "idle" ? fabLabel : "Cancel feedback",
589
+ title: isEdge ? mode.kind === "idle" ? fabLabel : "Cancel feedback" : void 0,
570
590
  onClick: startFlow,
571
- children: mode.kind === "idle" ? /* @__PURE__ */ jsxs(Fragment, { children: [
572
- /* @__PURE__ */ jsx(MessageIcon, { size: 18 }),
573
- " ",
574
- fabLabel
575
- ] }) : /* @__PURE__ */ jsxs(Fragment, { children: [
576
- /* @__PURE__ */ jsx(XIcon, { size: 18 }),
577
- " Cancel"
578
- ] })
591
+ children: [
592
+ mode.kind === "idle" ? /* @__PURE__ */ jsx(MessageIcon, { size: isEdge ? 22 : 18 }) : /* @__PURE__ */ jsx(XIcon, { size: isEdge ? 22 : 18 }),
593
+ !isEdge && /* @__PURE__ */ jsx("span", { children: mode.kind === "idle" ? fabLabel : "Cancel" })
594
+ ]
579
595
  }
580
596
  )
581
597
  ] })
@@ -121,7 +121,8 @@ function createNextRoute(config) {
121
121
  }
122
122
  function cookieGate(name, value = "1") {
123
123
  return (req) => {
124
- const cookie = req.headers.get("cookie") ?? "";
124
+ const { headers } = req;
125
+ const cookie = typeof headers.get === "function" ? headers.get("cookie") ?? "" : headers.cookie ?? "";
125
126
  return cookie.split(";").map((c) => c.trim()).some((c) => c === `${name}=${value}`);
126
127
  };
127
128
  }
@@ -70,8 +70,16 @@ type NextRouteConfig = FeedbackServerConfig & {
70
70
  authorize?: (req: Request) => boolean | Promise<boolean>;
71
71
  };
72
72
  declare function createNextRoute(config: NextRouteConfig): (req: Request) => Promise<Response>;
73
- /** Authorize helper: allow only requests carrying `name=value` in the Cookie header. */
74
- declare function cookieGate(name: string, value?: string): (req: Request) => boolean;
73
+ /**
74
+ * Authorize helper: allow only requests carrying `name=value` in the Cookie header.
75
+ *
76
+ * Works with BOTH a Web `Request` (Next.js App Router via `createNextRoute`) and a Node
77
+ * `IncomingMessage` (Vercel / Express via `createNodeHandler`). The two runtimes expose
78
+ * headers differently β€” `headers.get("cookie")` vs the plain `headers.cookie` string β€” so
79
+ * we feature-detect instead of assuming one shape. (Previously this only handled the Web
80
+ * `Request`, so the documented `createNodeHandler` usage threw `headers.get is not a function`.)
81
+ */
82
+ declare function cookieGate(name: string, value?: string): (req: Request | IncomingMessage) => boolean;
75
83
 
76
84
  type NodeHandlerConfig = FeedbackServerConfig & {
77
85
  allowedOrigin?: string;
@@ -70,8 +70,16 @@ type NextRouteConfig = FeedbackServerConfig & {
70
70
  authorize?: (req: Request) => boolean | Promise<boolean>;
71
71
  };
72
72
  declare function createNextRoute(config: NextRouteConfig): (req: Request) => Promise<Response>;
73
- /** Authorize helper: allow only requests carrying `name=value` in the Cookie header. */
74
- declare function cookieGate(name: string, value?: string): (req: Request) => boolean;
73
+ /**
74
+ * Authorize helper: allow only requests carrying `name=value` in the Cookie header.
75
+ *
76
+ * Works with BOTH a Web `Request` (Next.js App Router via `createNextRoute`) and a Node
77
+ * `IncomingMessage` (Vercel / Express via `createNodeHandler`). The two runtimes expose
78
+ * headers differently β€” `headers.get("cookie")` vs the plain `headers.cookie` string β€” so
79
+ * we feature-detect instead of assuming one shape. (Previously this only handled the Web
80
+ * `Request`, so the documented `createNodeHandler` usage threw `headers.get is not a function`.)
81
+ */
82
+ declare function cookieGate(name: string, value?: string): (req: Request | IncomingMessage) => boolean;
75
83
 
76
84
  type NodeHandlerConfig = FeedbackServerConfig & {
77
85
  allowedOrigin?: string;
@@ -119,7 +119,8 @@ function createNextRoute(config) {
119
119
  }
120
120
  function cookieGate(name, value = "1") {
121
121
  return (req) => {
122
- const cookie = req.headers.get("cookie") ?? "";
122
+ const { headers } = req;
123
+ const cookie = typeof headers.get === "function" ? headers.get("cookie") ?? "" : headers.cookie ?? "";
123
124
  return cookie.split(";").map((c) => c.trim()).some((c) => c === `${name}=${value}`);
124
125
  };
125
126
  }
@@ -0,0 +1,147 @@
1
+ 'use strict';
2
+
3
+ var sdk = require('@linear/sdk');
4
+
5
+ // src/server/core.ts
6
+ var MAX_NOTE = 5e3;
7
+ async function createFeedbackIssue(payload, config) {
8
+ const { apiKey, teamId } = config;
9
+ if (!apiKey || !teamId) throw new Error("not_configured");
10
+ const note = payload?.annotation?.note?.trim();
11
+ if (!note) throw new Error("note_required");
12
+ if (note.length > MAX_NOTE) throw new Error("note_too_long");
13
+ const { annotation, context } = payload;
14
+ const typeLabel = annotation.typeLabel || capitalize(annotation.type || "Feedback");
15
+ const linear = new sdk.LinearClient({ apiKey });
16
+ let assetUrl = null;
17
+ if (payload.screenshot?.startsWith("data:image/")) {
18
+ try {
19
+ assetUrl = await uploadScreenshot(linear, payload.screenshot);
20
+ } catch (err) {
21
+ console.error("[feedback] screenshot upload failed", err);
22
+ }
23
+ }
24
+ const description = [
25
+ note,
26
+ "",
27
+ "---",
28
+ assetUrl ? `![screenshot](${encodeURI(assetUrl)})` : "_No screenshot captured._",
29
+ "",
30
+ "**Context**",
31
+ `- Type: ${typeLabel}`,
32
+ context?.url ? `- Page: ${context.url}` : null,
33
+ annotation.name ? `- Reported by: ${annotation.name}` : null,
34
+ context?.userAgent ? `- User agent: ${context.userAgent}` : null,
35
+ context?.timestamp ? `- Submitted: ${context.timestamp}` : null
36
+ ].filter(Boolean).join("\n");
37
+ const title = `${typeLabel}: ${note.slice(0, 60)}${note.length > 60 ? "\u2026" : ""}`;
38
+ const labelName = config.labels?.[annotation.type] ?? annotation.type;
39
+ const labelId = labelName ? await resolveLabelId(linear, labelName, teamId) : null;
40
+ if (labelName && !labelId) console.warn(`[feedback] label "${labelName}" not found \u2014 creating issue without it`);
41
+ const create = async (ids) => {
42
+ const created = await linear.createIssue({ teamId, title, description, labelIds: ids.length ? ids : void 0 });
43
+ return await created.issue;
44
+ };
45
+ const issue = await create(labelId ? [labelId] : []).catch((err) => {
46
+ if (!labelId) throw err;
47
+ console.warn("[feedback] create failed with label, retrying without it", err);
48
+ return create([]);
49
+ });
50
+ return { id: issue?.id, identifier: issue?.identifier, url: issue?.url };
51
+ }
52
+ function capitalize(s) {
53
+ return s.charAt(0).toUpperCase() + s.slice(1);
54
+ }
55
+ async function resolveLabelId(linear, name, teamId) {
56
+ try {
57
+ const { nodes } = await linear.issueLabels({ filter: { name: { eqIgnoreCase: name } }, first: 50 });
58
+ if (nodes.length === 0) return null;
59
+ if (nodes.length === 1) return nodes[0].id;
60
+ const scored = await Promise.all(
61
+ nodes.map(async (n) => {
62
+ try {
63
+ const team = await n.team;
64
+ return { id: n.id, teamId: team?.id ?? null };
65
+ } catch {
66
+ return { id: n.id, teamId: null };
67
+ }
68
+ })
69
+ );
70
+ const pick = scored.find((s) => s.teamId === teamId) ?? scored.find((s) => s.teamId === null) ?? scored[0];
71
+ return pick.id;
72
+ } catch (err) {
73
+ console.warn("[feedback] label lookup failed", name, err);
74
+ return null;
75
+ }
76
+ }
77
+ async function uploadScreenshot(linear, dataUrl) {
78
+ const [meta, b64] = dataUrl.split(",");
79
+ const contentType = /data:(.*?);base64/.exec(meta)?.[1] ?? "image/jpeg";
80
+ const bytes = Buffer.from(b64, "base64");
81
+ const filename = `feedback-${Date.now()}.jpg`;
82
+ const upload = await linear.fileUpload(contentType, filename, bytes.length);
83
+ if (!upload.success || !upload.uploadFile) throw new Error("failed to request upload URL");
84
+ const headers = new Headers();
85
+ headers.set("Content-Type", contentType);
86
+ headers.set("Cache-Control", "public, max-age=31536000");
87
+ upload.uploadFile.headers.forEach(({ key, value }) => headers.set(key, value));
88
+ const put = await fetch(upload.uploadFile.uploadUrl, { method: "PUT", headers, body: new Uint8Array(bytes) });
89
+ if (!put.ok) throw new Error(`upload PUT failed: ${put.status}`);
90
+ return upload.uploadFile.assetUrl;
91
+ }
92
+
93
+ // src/server/node.ts
94
+ var BAD_REQUEST = /* @__PURE__ */ new Set(["note_required", "note_too_long", "bad_type", "bad_json", "not_configured"]);
95
+ function send(res, status, body) {
96
+ res.statusCode = status;
97
+ res.setHeader("content-type", "application/json");
98
+ res.end(JSON.stringify(body));
99
+ }
100
+ async function readJson(req) {
101
+ const chunks = [];
102
+ for await (const chunk of req) chunks.push(Buffer.from(chunk));
103
+ const raw = Buffer.concat(chunks).toString("utf8");
104
+ if (!raw) throw new Error("bad_json");
105
+ try {
106
+ return JSON.parse(raw);
107
+ } catch {
108
+ throw new Error("bad_json");
109
+ }
110
+ }
111
+ function createNodeHandler(config) {
112
+ return async function handler(req, res) {
113
+ try {
114
+ if (config.authorize && !await config.authorize(req)) return send(res, 404, { error: "unauthorized" });
115
+ if (config.allowedOrigin) {
116
+ const origin = req.headers.origin ?? "";
117
+ if (origin && origin !== config.allowedOrigin) return send(res, 403, { error: "forbidden_origin" });
118
+ }
119
+ const payload = req.body ?? await readJson(req);
120
+ const issue = await createFeedbackIssue(payload, config);
121
+ send(res, 200, { ok: true, ...issue });
122
+ } catch (err) {
123
+ const message = err instanceof Error ? err.message : String(err);
124
+ if (BAD_REQUEST.has(message)) return send(res, message === "not_configured" ? 500 : 400, { error: message });
125
+ console.error("[feedback] issue create failed", err);
126
+ send(res, 502, { error: "issue_create_failed", message });
127
+ }
128
+ };
129
+ }
130
+
131
+ // src/vite/index.ts
132
+ function linearFeedback(config) {
133
+ const endpoint = config.endpoint ?? "/api/feedback";
134
+ const handler = createNodeHandler(config);
135
+ return {
136
+ name: "react-linear-feedback",
137
+ apply: "serve",
138
+ configureServer(server) {
139
+ server.middlewares.use(endpoint, (req, res, next) => {
140
+ if (req.method !== "POST") return next();
141
+ handler(req, res).catch(next);
142
+ });
143
+ }
144
+ };
145
+ }
146
+
147
+ exports.linearFeedback = linearFeedback;
@@ -0,0 +1,27 @@
1
+ import { Plugin } from 'vite';
2
+ import { IncomingMessage } from 'node:http';
3
+
4
+ type FeedbackServerConfig = {
5
+ /** Linear personal API key (server-side secret). */
6
+ apiKey: string;
7
+ /** Target team UUID. */
8
+ teamId: string;
9
+ /**
10
+ * Map a type id (e.g. "bug") to a Linear label NAME. Defaults to the type id itself, so a type
11
+ * "bug" looks for a label named "bug". Labels are resolved by name at request time.
12
+ */
13
+ labels?: Record<string, string>;
14
+ };
15
+
16
+ type NodeHandlerConfig = FeedbackServerConfig & {
17
+ allowedOrigin?: string;
18
+ authorize?: (req: IncomingMessage) => boolean | Promise<boolean>;
19
+ };
20
+
21
+ type LinearFeedbackViteConfig = NodeHandlerConfig & {
22
+ /** Path the widget POSTs to; must match `<FeedbackGate endpoint>`. Default `/api/feedback`. */
23
+ endpoint?: string;
24
+ };
25
+ declare function linearFeedback(config: LinearFeedbackViteConfig): Plugin;
26
+
27
+ export { type LinearFeedbackViteConfig, linearFeedback };
@@ -0,0 +1,27 @@
1
+ import { Plugin } from 'vite';
2
+ import { IncomingMessage } from 'node:http';
3
+
4
+ type FeedbackServerConfig = {
5
+ /** Linear personal API key (server-side secret). */
6
+ apiKey: string;
7
+ /** Target team UUID. */
8
+ teamId: string;
9
+ /**
10
+ * Map a type id (e.g. "bug") to a Linear label NAME. Defaults to the type id itself, so a type
11
+ * "bug" looks for a label named "bug". Labels are resolved by name at request time.
12
+ */
13
+ labels?: Record<string, string>;
14
+ };
15
+
16
+ type NodeHandlerConfig = FeedbackServerConfig & {
17
+ allowedOrigin?: string;
18
+ authorize?: (req: IncomingMessage) => boolean | Promise<boolean>;
19
+ };
20
+
21
+ type LinearFeedbackViteConfig = NodeHandlerConfig & {
22
+ /** Path the widget POSTs to; must match `<FeedbackGate endpoint>`. Default `/api/feedback`. */
23
+ endpoint?: string;
24
+ };
25
+ declare function linearFeedback(config: LinearFeedbackViteConfig): Plugin;
26
+
27
+ export { type LinearFeedbackViteConfig, linearFeedback };
@@ -0,0 +1,145 @@
1
+ import { LinearClient } from '@linear/sdk';
2
+
3
+ // src/server/core.ts
4
+ var MAX_NOTE = 5e3;
5
+ async function createFeedbackIssue(payload, config) {
6
+ const { apiKey, teamId } = config;
7
+ if (!apiKey || !teamId) throw new Error("not_configured");
8
+ const note = payload?.annotation?.note?.trim();
9
+ if (!note) throw new Error("note_required");
10
+ if (note.length > MAX_NOTE) throw new Error("note_too_long");
11
+ const { annotation, context } = payload;
12
+ const typeLabel = annotation.typeLabel || capitalize(annotation.type || "Feedback");
13
+ const linear = new LinearClient({ apiKey });
14
+ let assetUrl = null;
15
+ if (payload.screenshot?.startsWith("data:image/")) {
16
+ try {
17
+ assetUrl = await uploadScreenshot(linear, payload.screenshot);
18
+ } catch (err) {
19
+ console.error("[feedback] screenshot upload failed", err);
20
+ }
21
+ }
22
+ const description = [
23
+ note,
24
+ "",
25
+ "---",
26
+ assetUrl ? `![screenshot](${encodeURI(assetUrl)})` : "_No screenshot captured._",
27
+ "",
28
+ "**Context**",
29
+ `- Type: ${typeLabel}`,
30
+ context?.url ? `- Page: ${context.url}` : null,
31
+ annotation.name ? `- Reported by: ${annotation.name}` : null,
32
+ context?.userAgent ? `- User agent: ${context.userAgent}` : null,
33
+ context?.timestamp ? `- Submitted: ${context.timestamp}` : null
34
+ ].filter(Boolean).join("\n");
35
+ const title = `${typeLabel}: ${note.slice(0, 60)}${note.length > 60 ? "\u2026" : ""}`;
36
+ const labelName = config.labels?.[annotation.type] ?? annotation.type;
37
+ const labelId = labelName ? await resolveLabelId(linear, labelName, teamId) : null;
38
+ if (labelName && !labelId) console.warn(`[feedback] label "${labelName}" not found \u2014 creating issue without it`);
39
+ const create = async (ids) => {
40
+ const created = await linear.createIssue({ teamId, title, description, labelIds: ids.length ? ids : void 0 });
41
+ return await created.issue;
42
+ };
43
+ const issue = await create(labelId ? [labelId] : []).catch((err) => {
44
+ if (!labelId) throw err;
45
+ console.warn("[feedback] create failed with label, retrying without it", err);
46
+ return create([]);
47
+ });
48
+ return { id: issue?.id, identifier: issue?.identifier, url: issue?.url };
49
+ }
50
+ function capitalize(s) {
51
+ return s.charAt(0).toUpperCase() + s.slice(1);
52
+ }
53
+ async function resolveLabelId(linear, name, teamId) {
54
+ try {
55
+ const { nodes } = await linear.issueLabels({ filter: { name: { eqIgnoreCase: name } }, first: 50 });
56
+ if (nodes.length === 0) return null;
57
+ if (nodes.length === 1) return nodes[0].id;
58
+ const scored = await Promise.all(
59
+ nodes.map(async (n) => {
60
+ try {
61
+ const team = await n.team;
62
+ return { id: n.id, teamId: team?.id ?? null };
63
+ } catch {
64
+ return { id: n.id, teamId: null };
65
+ }
66
+ })
67
+ );
68
+ const pick = scored.find((s) => s.teamId === teamId) ?? scored.find((s) => s.teamId === null) ?? scored[0];
69
+ return pick.id;
70
+ } catch (err) {
71
+ console.warn("[feedback] label lookup failed", name, err);
72
+ return null;
73
+ }
74
+ }
75
+ async function uploadScreenshot(linear, dataUrl) {
76
+ const [meta, b64] = dataUrl.split(",");
77
+ const contentType = /data:(.*?);base64/.exec(meta)?.[1] ?? "image/jpeg";
78
+ const bytes = Buffer.from(b64, "base64");
79
+ const filename = `feedback-${Date.now()}.jpg`;
80
+ const upload = await linear.fileUpload(contentType, filename, bytes.length);
81
+ if (!upload.success || !upload.uploadFile) throw new Error("failed to request upload URL");
82
+ const headers = new Headers();
83
+ headers.set("Content-Type", contentType);
84
+ headers.set("Cache-Control", "public, max-age=31536000");
85
+ upload.uploadFile.headers.forEach(({ key, value }) => headers.set(key, value));
86
+ const put = await fetch(upload.uploadFile.uploadUrl, { method: "PUT", headers, body: new Uint8Array(bytes) });
87
+ if (!put.ok) throw new Error(`upload PUT failed: ${put.status}`);
88
+ return upload.uploadFile.assetUrl;
89
+ }
90
+
91
+ // src/server/node.ts
92
+ var BAD_REQUEST = /* @__PURE__ */ new Set(["note_required", "note_too_long", "bad_type", "bad_json", "not_configured"]);
93
+ function send(res, status, body) {
94
+ res.statusCode = status;
95
+ res.setHeader("content-type", "application/json");
96
+ res.end(JSON.stringify(body));
97
+ }
98
+ async function readJson(req) {
99
+ const chunks = [];
100
+ for await (const chunk of req) chunks.push(Buffer.from(chunk));
101
+ const raw = Buffer.concat(chunks).toString("utf8");
102
+ if (!raw) throw new Error("bad_json");
103
+ try {
104
+ return JSON.parse(raw);
105
+ } catch {
106
+ throw new Error("bad_json");
107
+ }
108
+ }
109
+ function createNodeHandler(config) {
110
+ return async function handler(req, res) {
111
+ try {
112
+ if (config.authorize && !await config.authorize(req)) return send(res, 404, { error: "unauthorized" });
113
+ if (config.allowedOrigin) {
114
+ const origin = req.headers.origin ?? "";
115
+ if (origin && origin !== config.allowedOrigin) return send(res, 403, { error: "forbidden_origin" });
116
+ }
117
+ const payload = req.body ?? await readJson(req);
118
+ const issue = await createFeedbackIssue(payload, config);
119
+ send(res, 200, { ok: true, ...issue });
120
+ } catch (err) {
121
+ const message = err instanceof Error ? err.message : String(err);
122
+ if (BAD_REQUEST.has(message)) return send(res, message === "not_configured" ? 500 : 400, { error: message });
123
+ console.error("[feedback] issue create failed", err);
124
+ send(res, 502, { error: "issue_create_failed", message });
125
+ }
126
+ };
127
+ }
128
+
129
+ // src/vite/index.ts
130
+ function linearFeedback(config) {
131
+ const endpoint = config.endpoint ?? "/api/feedback";
132
+ const handler = createNodeHandler(config);
133
+ return {
134
+ name: "react-linear-feedback",
135
+ apply: "serve",
136
+ configureServer(server) {
137
+ server.middlewares.use(endpoint, (req, res, next) => {
138
+ if (req.method !== "POST") return next();
139
+ handler(req, res).catch(next);
140
+ });
141
+ }
142
+ };
143
+ }
144
+
145
+ export { linearFeedback };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-linear-feedback",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Drop-in React feedback widget: draw a box, write a note, and it captures a screenshot and opens a Linear issue. Framework-agnostic, self-contained styles, zero design-system dependencies.",
5
5
  "license": "MIT",
6
6
  "author": "Oliver Odgaard",
@@ -40,6 +40,11 @@
40
40
  "types": "./dist/server/index.d.ts",
41
41
  "import": "./dist/server/index.js",
42
42
  "require": "./dist/server/index.cjs"
43
+ },
44
+ "./vite": {
45
+ "types": "./dist/vite/index.d.ts",
46
+ "import": "./dist/vite/index.js",
47
+ "require": "./dist/vite/index.cjs"
43
48
  }
44
49
  },
45
50
  "scripts": {
@@ -48,29 +53,30 @@
48
53
  "prepublishOnly": "npm run build"
49
54
  },
50
55
  "dependencies": {
56
+ "@linear/sdk": "^86.0.0",
51
57
  "modern-screenshot": "^4.7.0"
52
58
  },
53
59
  "peerDependencies": {
54
- "@linear/sdk": ">=40",
55
60
  "react": ">=18",
56
- "react-dom": ">=18"
61
+ "react-dom": ">=18",
62
+ "vite": ">=5"
57
63
  },
58
64
  "peerDependenciesMeta": {
59
- "@linear/sdk": {
65
+ "react-dom": {
60
66
  "optional": true
61
67
  },
62
- "react-dom": {
68
+ "vite": {
63
69
  "optional": true
64
70
  }
65
71
  },
66
72
  "devDependencies": {
67
- "@linear/sdk": "^86.0.0",
68
73
  "@types/node": "^22.0.0",
69
74
  "@types/react": "^19.0.0",
70
75
  "@types/react-dom": "^19.0.0",
71
76
  "react": "^19.0.0",
72
77
  "react-dom": "^19.0.0",
73
78
  "tsup": "^8.5.0",
74
- "typescript": "^5.7.0"
79
+ "typescript": "^5.7.0",
80
+ "vite": "^6.0.0"
75
81
  }
76
82
  }